Skip to content

feat(evals): passive/interactive agent eval framework over memory2 - #3411

Open
spomichter wants to merge 16 commits into
mainfrom
feat/evals-framework
Open

feat(evals): passive/interactive agent eval framework over memory2#3411
spomichter wants to merge 16 commits into
mainfrom
feat/evals-framework

Conversation

@spomichter

@spomichter spomichter commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Problem

Eval framework, supports both InteractiveEvals and PassiveEvals, @paul-nechifor old dimsim spatial memory eval replicated via the new framework here pretty clean:

BED = Vector3(-3.567, -1.332, 0.0)

go_to_bed = InteractiveEval(
    id="go_to_bed",
    inputs="go to the bed",
    score=lambda s: ramp((BED - s.streams.odom.last().data.position).length(), band=2.0),
    aggregate=final,          # or floor ("never left the zone") / mean / auc later
    blueprint="unitree-go2-agentic go2-memory",
    simulator="dimsim",
    scene="apartment",
)

Uses langchain openevals for VQA formatting so its compatible with all public benchmarks we'd want to run. Scoring is generic and done via lambda one-liners. Agent loop runs LLM queries with a light agent_loop in the EvalRunner -- kept minimal and using langchain client since just doing dumb QA.

Closes DIM-1392, DIM-1390

Solution

  • new dimos/evals/ package. one word (evals) everywhere, no benchmark vs eval split
  • two case types: PassiveEval (frozen mem2 recording, any replay window, non-autoregressive) and InteractiveEval (live sim/robot, actions mutate state, scored by sampling teh live recorder store)
  • memory2 the source of truth for all eval input — context selectors return real Streams, no copies, no parallel data structres
  • scoring is jsut lambdas over typed msg arithmetic (within, ramp, Vector3 subtraction etc), graded [0,1] credit not only pass/fail
  • we use the langchain/openevals standard for vqa formatting (inputs / reference_outputs, evaluators wrapped not subclassed) so external benchmarks map on natively and we can test against them
  • generic typing: PassiveEval[T] ties expected/parse/score togehter so mypy catches a mismatched case at suite definition time, not mid-run
  • runnable via cli (dimos evals run ...), mcp (EvalModule skills so coding agents can iterate), and pytest
  • --blind ablation built in — same suite with observations withheld, alredy caught two guessable MCQs
  • MoondreamChat adapter so evals run keyless on any gpu box

defining evals

passive one-off over a replay — the whole thing is one literal:

from dimos.evals.scorers import first_number, within, yes_no, exact
from dimos.evals.types import PassiveEval, InteractiveEval, Suite

# numeric, graded: 1.0 exact, linear to 0 at band
distance = PassiveEval(
    id="distance_10min",
    inputs="How far have you traveled in the last 10 minutes, in meters?",
    expected=142.0,
    parse=first_number,
    score=within(15.0),
    context=(lambda s: s.streams.odom.range_time(0, 600),),   # real mem2 stream
    dataset="go2_hongkong_office",
)

# vqa over a 10-image window
person = PassiveEval(
    id="person_visible",
    inputs="Is a person visible in any of these images?",
    expected="yes",
    parse=yes_no,
    score=exact,
    context=(lambda s: s.streams.color_image.range_time(58, 61).limit(10),),
    dataset="go2_short",
)

interactive — the case names its environment, score is sampled from the live mem2 store every interval_s and reduced by aggregate:

from dimos.evals.scorers import final, ramp
from dimos.msgs.geometry_msgs.Vector3 import Vector3

BED = Vector3(-3.567, -1.332, 0.0)

go_to_bed = InteractiveEval(
    id="go_to_bed",
    inputs="go to the bed",
    score=lambda s: ramp((BED - s.streams.odom.last().data.position).length(), band=2.0),
    aggregate=final,          # or floor ("never left the zone") / mean / auc later
    blueprint="unitree-go2-agentic go2-memory",
    simulator="dimsim",
    scene="apartment",
)

running

from dimos.evals.runner import EvalRunner, summarize

results = EvalRunner().run(SUITE)                    # prod model config (gpt-5.6-luna)
results = EvalRunner(blind=True).run(SUITE)          # guessing ablation
results = EvalRunner(chat_model=MoondreamChat()).run(SUITE)  # keyless local
print(summarize(results))

or dimos evals run dimos.evals.suites.go2_smoke --blind. every run writes results.jsonl + summary.json + per-case transcripts to ~/.local/state/dimos/evals/run-*/.

first real numbers

luna sighted: examples 1.00 / smoke 1.00 / vqa 0.86, blind = refusals. interactive rig scored 0.856 on a scripted go-to-bed in dimsim. the full agentic go-to-bed scores 0.0 right now — unitree-go2-agentic publishes no /odom in dimsim on current main (upstream test_dimsim_spatial_memory fails teh same way, suspect the control coordinator refactor). separate ticket coming.

Breaking Changes

None

How to Test

uv run pytest dimos/evals dimos/codebase_checks -q          # offline unit + wiring tests, no keys
uv run pytest dimos/evals/test_smoke.py -m self_hosted      # live model smoke (needs OPENAI_API_KEY + lfs data)
dimos evals run dimos.evals.suites.examples                 # 2 doc cases against go2_short
dimos evals run dimos.evals.suites.go2_smoke --blind        # guessing ablation

interactive (needs deno + display): dimos evals run dimos.evals.suites.dimsim_house --live-db recording_go2.db

Contributor License Agreement

  • I have read and approved the CLA

EvalCase/PassiveEval/InteractiveEval with EvalRig protocol dispatch, EvalRunner
implementing the rig (model call / mcp skill / agent loop / live-store sampling),
scorers as plain functions wrapping openevals, generated + hand VQA suites over
go2 replays, dimsim go-to-bed interactive suite, dimos evals CLI + EvalModule
MCP skills. extracts _init_model to dimos/agents/model.py for shared use.
@codecov

codecov Bot commented Aug 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 83.86667% with 121 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
dimos/evals/runner.py 74.27% 51 Missing and 11 partials ⚠️
dimos/evals/suites/dimsim_house.py 40.00% 21 Missing ⚠️
dimos/evals/cli.py 33.33% 16 Missing ⚠️
dimos/evals/scorers.py 78.94% 8 Missing ⚠️
dimos/evals/types.py 91.76% 5 Missing and 2 partials ⚠️
dimos/evals/test_evals.py 97.83% 4 Missing ⚠️
dimos/evals/test_mem2_wiring.py 97.19% 1 Missing and 2 partials ⚠️
@@            Coverage Diff             @@
##             main    #3411      +/-   ##
==========================================
- Coverage   76.05%   75.94%   -0.12%     
==========================================
  Files        1201     1219      +18     
  Lines      117016   118253    +1237     
  Branches    10530    10680     +150     
==========================================
+ Hits        88996    89803     +807     
- Misses      24974    25383     +409     
- Partials     3046     3067      +21     
Flag Coverage Δ
OS-ubuntu-24.04-arm 70.29% <82.00%> (-0.05%) ⬇️
OS-ubuntu-latest 72.09% <82.00%> (-0.09%) ⬇️
Py-3.10 72.08% <82.00%> (-0.08%) ⬇️
Py-3.11 72.09% <82.00%> (-0.08%) ⬇️
Py-3.12 72.09% <82.00%> (-0.08%) ⬇️
Py-3.13 72.08% <82.00%> (-0.08%) ⬇️
Py-3.14 72.09% <82.00%> (-0.08%) ⬇️
Py-3.14t 72.09% <82.00%> (-0.08%) ⬇️
SelfHosted-Large 29.69% <31.89%> (+0.01%) ⬆️
SelfHosted-Linux 35.80% <45.57%> (+0.05%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
dimos/cli/dimos.py 64.78% <100.00%> (+0.15%) ⬆️
dimos/evals/suites/examples.py 100.00% <100.00%> (ø)
dimos/evals/suites/go2_smoke.py 100.00% <100.00%> (ø)
dimos/evals/suites/go2_vqa.py 100.00% <100.00%> (ø)
dimos/evals/test_smoke.py 100.00% <100.00%> (ø)
dimos/robot/all_blueprints.py 100.00% <ø> (ø)
dimos/evals/test_mem2_wiring.py 97.19% <97.19%> (ø)
dimos/evals/test_evals.py 97.83% <97.83%> (ø)
dimos/evals/types.py 91.76% <91.76%> (ø)
dimos/evals/scorers.py 78.94% <78.94%> (ø)
... and 3 more

... and 19 files with indirect coverage changes

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

…anges

reuse mcp_client._init_model lazily instead of extracting it — keeps this PR
scoped to dimos/evals (+ cli registration). extraction can be its own PR if
we want it shared properly.
@spomichter
spomichter marked this pull request as ready for review August 9, 2026 07:42
@spomichter

Copy link
Copy Markdown
Contributor Author

@greptile review

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@TomCC7

TomCC7 commented Aug 10, 2026

Copy link
Copy Markdown
Member

We need to design runtime and scorer information access more carefully. The current scheme is wrong IMO because it evaluates against Memory2 state produced by the runtime itself. To simulate real operation as faithfully as possible, the agent should have access to all information exposed by the running blueprint, including RPCs, streams, and Memory2. Scoring should instead use independent, privileged simulator state, such as ground-truth robot and object poses and object types.

mustafab0
mustafab0 previously approved these changes Aug 11, 2026
@github-actions github-actions Bot removed the ready-to-merge Required CI checks have passed on this PR label Aug 11, 2026
@github-actions github-actions Bot added the ready-to-merge Required CI checks have passed on this PR label Aug 11, 2026
Comment thread dimos/evals/intro.md Outdated
@@ -0,0 +1,202 @@
# Evals Intro

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should be in /docs/ as dimos evals is a command that's shipped to everyone.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

okay yes

@mintlify

mintlify Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
dimensional 🟢 Ready View Preview Aug 12, 2026, 2:14 AM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@github-actions github-actions Bot removed the ready-to-merge Required CI checks have passed on this PR label Aug 12, 2026
@github-actions github-actions Bot added the ready-to-merge Required CI checks have passed on this PR label Aug 12, 2026
Comment thread dimos/evals/runner.py Outdated
# House convention (StoreConfig): pass an instance to inject, e.g. a fake
# chat model in tests. None -> built from `model` like McpClient does.
chat_model: Any | None = None
mcp_url: str = "http://localhost:9990/mcp"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mcp_port is in global_config. You should probably use that.

Comment thread dimos/evals/runner.py Outdated
context_budget: int = 8 # max observations encoded per context Select
attach: bool = False # True: drive an already-running dimos
launch_timeout_s: float = 1200.0 # blueprint + MCP readiness (e2e parity)
out_dir: Path = Path("~/.local/state/dimos/evals").expanduser()

@paul-nechifor paul-nechifor Aug 12, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should use STATE_DIR / "evals" with STATE_DIR from constants.py

Comment thread dimos/evals/runner.py
store.stop()
return series

def _wait_live_store(self, deadline: float) -> Store:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like the way this works. It assumes that a store is on disk and also that it's a file. That's very specific to Sqlite. Other DBs don't create a single file for a DB.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think what should happen here is that class Store should be changed to have something like Store.wait_for_creation (or a better name) and this sort of internal stuff should be performed in SqliteStore.wait_for_creation instead of in unrelated places like here.

Comment thread dimos/evals/runner.py Outdated
def instruct(self, text: str) -> None:
from dimos.core.transport import pLCMTransport

transport: pLCMTransport[str] = pLCMTransport("/human_input")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should use transport = make_transport("/human_input") since we shouldn't be strictly tied to LCM now.

Comment thread dimos/evals/runner.py Outdated
transport.lcm.start()
try:
transport.publish(text)
time.sleep(0.5) # let LCM flush before teardown

@paul-nechifor paul-nechifor Aug 12, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you sure this is an issue? I don't think I've encountered it. If it is, we should have a transport.flush() rather than a random wait as it would be a problem with transports in general, not just here.

Comment thread dimos/evals/runner.py Outdated
transport.publish(text)
time.sleep(0.5) # let LCM flush before teardown
finally:
transport.lcm.stop()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to call transport.lcm.start()/.stop() anymore. Just use transport.start()/.stop()

Comment thread dimos/evals/runner.py
from dimos.evals.types import _no_setup

if case.simulator and not self.config.attach:
from dimos.e2e_tests.dimos_cli_call import DimosCliCall

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since dimos_cli_call is used in dimos/evals now too, it should be moved out of dimos/e2e_tests as it's no longer just for end to end testing. Maybe somewhere in dimos/cli?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True good call -- ill do a following PR just to keep this PR clean and consoludated

Comment thread dimos/evals/runner.py
proc.global_args = ["--dimsim-scene", case.scene]
proc.demo_args = ["run", *case.blueprint.split()]
proc.start()
self._proc = proc

@paul-nechifor paul-nechifor Aug 12, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The architecture here seems a bit unclear to me.

If I understand this correctly, InteractiveEval gets an EvalRunner and sets itself up on it by calling eval_runner.setup_env(self)

I see EvalRunner as a parent object of multiple InteractiveEval objects. It's generally bad for children to control their parent object instead of the other way arround.

For example, EvalRunner.setup_env sets self._proc = DimosCliCall(). This creates a new process for every eval. When EvalRunner.stop is called, only the last self._proc is ever stopped.

This issue is obscured by the fact that an eval runs itself on the eval runner instead of the eval runner being in charge of running the evals. That is, the fact that EvalRunner._proc is leaked is hard to see because it's ultimately set from InteractiveEval.evaluate.

But also, _proc probably doesn't belong on EvalRunner. It should be on InteractiveEval. Evals should probably have a life cycle where they can be created and destroyed.


Edit: So EvalRunner.run does in fact run the evals, but also the evals know too much about the eval runner and store state on it because they can't store state on themselves.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

evalrunner runs the interactive/passive evals. not a parent.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you epxosed a process issue though adding per eval case teardown

Per review: use the transport factory instead of pinning pLCMTransport,
use transport.start()/stop(), and remove the 0.5s flush wait — LCM
publish is a synchronous send, verified by a 50-round teardown race test
(0 lost) and covered by test_instruct_delivers_over_real_transport.
…ATE_DIR

Per review: mcp_url default None resolves to global_config.mcp_port
(matching McpAdapter), and out_dir uses constants.STATE_DIR so the GLib
state-dir branch lands in the right place.
Per review: setup_env overwrote _proc each case so only the last sim
process was ever stopped, and the DimSimClient from case.setup was never
stopped. _guarded now calls teardown_env() in a finally, the runner owns
env lifecycle, and a fake-process test pins one-launch-one-stop per case
including the failure path.
@github-actions github-actions Bot removed the ready-to-merge Required CI checks have passed on this PR label Aug 13, 2026
@spomichter
spomichter force-pushed the feat/evals-framework branch from 65ee980 to 32b3b1b Compare August 13, 2026 09:14
@github-actions github-actions Bot added the ready-to-merge Required CI checks have passed on this PR label Aug 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

PlzReview ready-to-merge Required CI checks have passed on this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants